import { NextRequest, NextResponse } from 'next/server'; import { LoginResponse } from '@/types/response/auth'; import { fetchJson } from '@/lib/utils/server'; import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS, OAUTH_STATE_COOKIE_PREFIX } from '@/constants/social'; /** * 소셜 로그인 콜백 라우트 (Naver/Kakao) — /login/{provider}/callback * * 1. ?code&state(&error) 수신 → start 라우트가 심은 httpOnly state 쿠키와 대조 (CSRF 방어) * 2. 백엔드 POST /api/auth/social-login (code 교환 + userinfo + 계정 매칭) * 3. 성공: accessToken/refreshToken httpOnly 쿠키(기존 google/callback 옵션 동일) → /login/{provider}/complete * 4. Auth.EmailRequired / Auth.AccountExists: 안내 화면(/login/social-notice)으로 분기 * 5. 기타 실패: /login?error={message} (로그인 페이지 alert 관례) */ export async function GET(request: NextRequest, { params }: { params: Promise<{ provider: string }> }) { const { provider } = await params; if (!isSocialLoginProvider(provider)) { const url = new URL('/login', request.url); url.searchParams.set('error', '지원하지 않는 소셜 로그인입니다.'); return NextResponse.redirect(url); } const label = SOCIAL_PROVIDER_LABELS[provider]; const stateCookieName = `${OAUTH_STATE_COOKIE_PREFIX}${provider}`; const savedState = request.cookies.get(stateCookieName)?.value ?? null; // state 쿠키는 일회용 — 성공/실패와 무관하게 응답에서 제거 const finish = (response: NextResponse) => { response.cookies.delete(stateCookieName); return response; }; const failRedirect = (message: string) => { const url = new URL('/login', request.url); url.searchParams.set('error', message); return finish(NextResponse.redirect(url)); }; const noticeRedirect = (reason: 'email-required'|'account-exists') => { const url = new URL('/login/social-notice', request.url); url.searchParams.set('reason', reason); url.searchParams.set('provider', provider); return finish(NextResponse.redirect(url)); }; const searchParams = request.nextUrl.searchParams; const code = searchParams.get('code'); const state = searchParams.get('state'); const oauthError = searchParams.get('error'); const oauthErrorDescription = searchParams.get('error_description'); // 사용자가 동의 화면에서 취소한 경우 등 provider 측 에러 if (oauthError) { if (oauthError === 'access_denied') { return failRedirect(`${label} 로그인이 취소되었습니다.`); } return failRedirect(oauthErrorDescription || `${label} 인증에 실패했습니다. 다시 시도해주세요.`); } if (!code) { return failRedirect(`${label} 인증 코드가 없습니다. 다시 시도해주세요.`); } if (!state || !savedState || state !== savedState) { return failRedirect('로그인 요청 검증(state)에 실패했습니다. 다시 시도해주세요.'); } try { const redirectUri = `${request.nextUrl.origin}/login/${provider}/callback`; const res = await fetchJson('/api/auth/social-login', { method: 'POST', body: JSON.stringify({ Provider: provider, Code: code, State: state, RedirectUri: redirectUri }) }); if (res.success && res.data) { const response = NextResponse.redirect(new URL(`/login/${provider}/complete`, request.url)); const cookieOptions = { httpOnly: true, path: '/' }; response.cookies.set('accessToken', res.data.accessToken, cookieOptions); response.cookies.set('refreshToken', res.data.refreshToken, cookieOptions); return finish(response); } const errorCode = res.errors?.[0]?.code; // 카카오 이메일 선택동의 미동의 → 이메일 제공 동의 안내 화면 if (errorCode === 'Auth.EmailRequired') { return noticeRedirect('email-required'); } // 같은 이메일의 기존 계정 존재 (자동연결 금지) → 기존 계정 로그인 + SNS 연동 안내 화면 if (errorCode === 'Auth.AccountExists') { return noticeRedirect('account-exists'); } if (errorCode === 'Auth.SocialNotConfigured') { return failRedirect(`${label} 로그인이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.`); } return failRedirect(res.errors?.[0]?.description || res.message || `${label} 로그인에 실패했습니다. 다시 시도해주세요.`); } catch (e) { console.error(`[${provider} Callback] Error:`, e); return failRedirect(e instanceof Error ? e.message : `${label} 로그인 처리 중 오류가 발생했습니다.`); } }